Skip to content

Merge pull request #2647 from Vacademy-io/feature/tts-speech-cache - #2648

Open
Manshu48 wants to merge 271 commits into
feature/tts-speech-cachefrom
main
Open

Merge pull request #2647 from Vacademy-io/feature/tts-speech-cache#2648
Manshu48 wants to merge 271 commits into
feature/tts-speech-cachefrom
main

Conversation

@Manshu48

Copy link
Copy Markdown
Contributor

Feature/tts speech cache

Summary of Changes

Related Issue

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

How Has This Been Tested?

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing tests pass locally with my changes
  • I have updated the documentation accordingly

@codacy-production

codacy-production Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Not up to standards ⛔

🔴 Issues 2 high · 45 medium · 53 minor

Alerts:
⚠ 100 issues (≤ 0 issues of at least minor severity)

Results:
100 new issues

Category Results
Compatibility 11 medium
BestPractice 33 medium
4 minor
ErrorProne 2 high
Comprehensibility 1 minor
CodeStyle 48 minor
Complexity 1 medium

View in Codacy

🟢 Metrics 8198 complexity

Metric Results
Complexity 8198

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploying frontend-admin-dashboard with  Cloudflare Pages  Cloudflare Pages

Latest commit: beec2ed
Status: ✅  Deploy successful!
Preview URL: https://defb7894.frontend-admin-dashboard.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploying frontend-learner-dashboard-app with  Cloudflare Pages  Cloudflare Pages

Latest commit: 60271c2
Status: ✅  Deploy successful!
Preview URL: https://50550c5c.frontend-learner-dashboard-app.pages.dev

View logs

…TY_SERVICE_BASE_URL env var

CommunityAppRegistryClient's @value("${community.server.baseurl}") had no matching
property anywhere, so it silently fell back to its localhost default in every
deployed environment — every institute's app-status lookup was failing closed
(empty list, never an error) because admin_core_service could never actually
reach community_service. Bridges it the same way every sibling client
(assessment/auth/media/notification) already does per environment.
…rvice-baseurl

fix(app-status): wire community.server.baseurl for the app-status feature
Manshu48 and others added 4 commits August 25, 2026 10:35
…y cover

Reported as "7 Days / 30 Days / 3 Months all show the same numbers". The toggle
itself is wired correctly - period is in the query key, each period maps to a
different start date, and the backend filters meeting_date BETWEEN start AND
end. Verified against prod for a Suchbliss learner:

    7d  -> 8 class days (18-25 Aug)
    30d -> 9 class days (17-25 Aug)
    90d -> 9 class days (17-25 Aug)

So 30d and 3mo really are identical, but not because of a query bug: the same
backend query also floors results at the learner's enrolment date, and this
studio has no sessions before 17 Aug. Widening the window finds nothing more.

Two things were genuinely wrong, and one made the above unreadable:

1. Off-by-one in every window. meeting_date BETWEEN is inclusive at BOTH ends,
   so subtracting the full period length made each window one day too long -
   "7 Days" spanned 8 calendar days and reported 8 class days, which is exactly
   the confusing 7/8 on screen. Window lengths now live in one PERIOD_DAYS map
   and subtract length-1. Note "3 Months" is now exactly 90 days (matching its
   "90d" key) rather than three calendar months.

2. The Mon-Sun day strip never followed the period - it comes from
   useWeeklyAttendanceQuery, which is hardcoded to the current week and takes no
   period argument. That is fine, but nothing said so, so half the card looked
   frozen when the period changed. It is now labelled "This week" in all three
   theme branches.

3. New: when the window is clamped (by enrolment date or by a studio with no
   older sessions) the card states the span it actually covers - "9 class days
   since 17 Aug" - so a 30-day view showing 9 days no longer reads as broken.
   computeAttendanceStats gains firstClassDay for this; existing callers only
   consume the result, so the extra field is additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(voice-bot): export, push-now and flush routes for the speech-cac…
… chars

13a191b added three columns to summary()'s SELECT — [13] the count of calls
that measured, [14] the characters, [15] the seconds of the calls that did not —
but read [13] and [14]. So measuredChars got a CALL COUNT and unmeasuredMins got
CHARACTERS divided by sixty.

Both errors push the same way. A group with 390k characters billed roughly 6,500
phantom minutes of TTS, while the measured half contributed almost nothing. The
commit is titled "cost TTS from the characters actually synthesised" and did the
opposite of that.

Nothing shifted these indices; they were written this way. And no test or
compiler can catch it — every column is a Number, so the wrong one reads
cleanly. This is the same failure mode as the ::bigint break: a native query's
column order is a contract that only holds by inspection.

breakdown() is unaffected — it takes chars from diagInt(r[18], "chars"), parsed
out of the diagnostics blob rather than by position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manshu48 and others added 2 commits August 25, 2026 11:11
main now carries the TTS speech cache (PR #2656), which landed after this branch
forked, so both sides had edited the same cost and cache code. Seven hunks over
five files; six were one-sided and resolve by keeping both features:

  SuperAdminCallDTO       branch's ttsCharsMeasured, moved above the cache
                          comment so that comment still documents its own fields
  SuperAdminCallService   main's ttsCacheSummary; branch's .ttsCharsMeasured()
  bot.py / main.py        main's reporter task, its entry in the cancel tuple,
                          and the three internal tts-cache routes
  providers.py            branch's cross-engine pronunciation fix

The seventh needed judgement. Both sides had independently added the SAME column
to summary()'s SELECT:

    count(*) FILTER (WHERE r.diagnostics->'tts'->>'cacheHits' ~ '^[0-9]+$')

so the obvious resolution — take both sides — emits it twice and silently shifts
every column after it. Resolved to the branch side alone, which already contains
main's column plus its two new ones.

Verified after the fact rather than assumed: the merged SELECT has 16 columns and
all six index reads line up ([10] gHits, [11] gMiss, [12] gChars, [13]
cacheMeasured, [14] measuredChars, [15] unmeasuredMins). A native query's column
order is a contract nothing but inspection enforces — every column is a Number,
so the wrong one reads cleanly.

admin-core compiles; the voice-bot suite is 365 passed with only the 8 known
Windows %-d failures, none new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(tts-cache): export the ledger rows that predate per-agent provenance
Shreyash Jain and others added 10 commits August 25, 2026 11:21
TTS_CACHE_MIN_SEEN defaults to 1.

The threshold hedged against one-off sentences — "Namaste Rohan ji" for a name
that never recurs — but the arithmetic does not support the hedge. Counting
vendor payments for a line spoken N times: no cache costs N; at 2 it costs 3
(two live utterances plus the render) and is free from the third use; at 1 it
costs 2 and is free from the SECOND. So 1 wins whenever the line recurs at all,
and loses exactly one render when it never does.

The first day of real data settled it. Of shreya-v3's 73 ledger sentences, 54
sat at a single sighting and none had failed to render — the machinery was fine,
the bar was simply holding back the entire backlog. Rendering all 54
speculatively costs 4,237 characters, about Rs 7, and happens off-call where it
buys no latency penalty. The hedge was costing more than it saved.

Fixed lines already rendered on the first sighting; this brings LLM sentences to
the same footing for a different reason — not "it will certainly recur" but "if
it does, we are already ahead".

The knob is unchanged and still honoured, which is the way back if the ledger
ever shows a fat never-recurring tail. A test pins the default, because nothing
FAILS when this drifts back to 2 — the cache just quietly stops earning on
everything with a short tail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(tts-cache): render a sentence on its first sighting, not its second
… speech

Reported from live call f425326e: cached audio restarted from the top when the
caller interrupted, and some sentences repeated far too often. Both are the same
missing frame.

smallest and sarvam are constructed with word_timestamps=True, so pipecat sets
push_text_frames=False and builds TTSTextFrames from the vendor's word-timing
messages instead of appending one after run_tts. A cache hit never calls the
vendor, so those messages never arrive — and a served sentence emitted ZERO
TTSTextFrames.

Everything that decides "has the bot already said this?" reads that frame.
PlayedTranscriptRecorder logs only TTSTextFrame; NoRepeatGate tests containment
in what it logged. With none emitted, a cached sentence was permanently "never
said", so nothing suppressed it and nothing recorded it in the assistant
context. The model was free to say it again, indefinitely.

The audio shows it plainly. On that call one 8.2s line played THREE times — at
459s, 475s and 499s — with envelope correlation 0.966 and 0.996 between the
plays. That is the same blob replayed, not a re-synthesis, which would vary in
prosody because temperature is non-zero. A 3.4s line restarted from the top 1.2s
after being cut, at correlation 0.998. Diagnostics agree: REPLY_LOOP,
maxReplyRestarts 2, and 30 unsaid-reverts against 28 cache hits.

The frame goes AFTER the audio, which is where the base class appends its own
(tts_service.py:1129, after tts_process_generator returns). The transport
releases it at playout position, so a sentence the caller talked over is still
correctly treated as NOT heard — "'already said' has to mean 'already HEARD'".
Emitting it before the audio would fix the looping and reintroduce the bug that
revert exists to prevent.

aggregated_by is SENTENCE: G1 only admits complete sentences to the cache, and
that is pipecat's default aggregation mode, so the frame is indistinguishable
from one the engine produced. The miss path is untouched and asserted to stay
that way — pipecat still owns the text frame there, and a second one would
duplicate the sentence into the transcript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit emitted it unconditionally, which is right for smallest and
wrong for everything else. sarvam, deepgram and google all set
push_text_frames=True, so pipecat appends its OWN TTSTextFrame after run_tts
returns — ours would have been a second one, putting every cached sentence into
the played transcript and the assistant context TWICE. The model would see
itself say the line twice, and the repeat check would compare doubled text.

smallest is the only engine that needs ours, because it is built with
word_timestamps=True (push_text_frames=not word_timestamps), so pipecat builds
the text frames from the vendor's word-timing messages instead. A cache hit never
calls the vendor, those messages never arrive, and nothing emits the frame at
all — which is the bug on live call f425326e.

owns_text_frame() makes the choice explicit, alongside owns_turn_brackets(),
which answers the same "emit exactly what the engine would have" question for
the start/stop pair. It defaults to NOT emitting on an unknown service: a missing
frame degrades the repeat check, a duplicated one corrupts the transcript, and
the recoverable failure is the better default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
neerajhariyale and others added 5 commits August 28, 2026 19:22
revert: take the checkout step rail off the public catalogue
…-preserved

fix(catalogue): reopen Course Finder on "Back to courses" without wip…
My previous fix was half right. admin_core's database does not contain
`user_role` or `roles` either — only `users` was named in the first error,
so I assumed the other two were local. They are not: the roster then failed
with `relation "user_role" does not exist`.

There is no in-process way for admin_core to ask "who works here".
UserRoleRepository lives in common_service, so it injects and compiles
anywhere, but every one of its queries hits auth_service-only tables and
fails at runtime. Documented that on the interface itself, since nothing
else makes it visible before production.

Roster and the from-staff eligibility check now both go through
AuthService. Added requireUsersByInstituteAndRoles: the existing method
swallows failures and returns an empty list, which is right for scheduled
reporting but wrong for a roster, where it would render an unreachable
auth_service as an institute that employs nobody.

Two behaviours this changes, both documented on the class: auth_service
returns ACTIVE memberships only, so INVITED staff no longer appear; and
UserDTO.roles spans all of a user's institutes, so it is intersected with
STAFF_ROLES before display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shreyash Jain and others added 8 commits August 29, 2026 09:48
…orking page

Most people arriving with an AI-built page are not technical, and every way it
fails here is silent: styling vanishes because <style> is not an allowed tag,
buttons do nothing because a raw href escapes the router, images 404 because
they point at a local folder. No error appears — the page just looks wrong.

Teaching those rules to the admin is the wrong fix. Encoding them in a prompt
the AI reads is the right one: the constraints get applied by the tool writing
the markup, and the admin only has to paste twice.

Adds a "No page yet? Build one with ChatGPT or Claude" block ABOVE the paste
fields — above, because someone without a page needs it before they need
anywhere to paste — with four steps and one-click copy of a prompt specifying:
HTML and CSS as separate blocks, no document wrapper, no scripts, no forms, no
external fonts or stylesheets, inline SVG welcome, https placeholder images,
responsive by default, and the data-vacademy hooks for buttons.

The prompt stays ENGLISH even in a translated UI: it is read by ChatGPT or
Claude rather than the admin, these are technical instructions a translation
could break, and it carries a LANGUAGE section telling the model to write page
copy in whatever language the admin describes it in. UI strings ARE translated
— en, ar, fr, hi all updated, none left to fall back.

Scope note: a teammate has since added a visual link editor to this panel that
points each button at a page or lead form with no HTML editing. That solves the
"make the buttons work" half, so this adds only the "I have no page" half
rather than duplicating it.

The i18n hook rejected the first attempt for pl-4 and mr-1.5 — physical
directions that do not mirror. That matters precisely because this change adds
Arabic: the list indent and icon gap would have sat on the wrong side. Now ps-4
and me-1.5.

Prompt verified to cover all eleven failure modes seen importing a real bundle.
tsc EXIT=0 by exit code, not a grep count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IMAGE_CLIP was missing from both per-shot-type default maps. It fell through
to composition=left_column and background_treatment=brand_solid, so in the
`input_image_screenshot` domain — where IMAGE_CLIP is the PRIMARY shot type —
almost every shot was told to build a 5-of-12-column text layout on a flat
brand background while also embedding the user's screenshot full-frame. The
two contracts contradict each other.

IMAGE_CLIP now takes the same pair as SOURCE_CLIP: full_bleed_overlay over
media_hero. media_hero is the half that matters for a run of screenshots — it
earns the no-repeat exemption, without which every second screenshot got
reflowed back into a column.

shot_planner's map claims to mirror automation_pipeline's; the pipeline side
already had IMAGE_CLIP as media_hero, so this restores the mirror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Indexed videos and indexed images share one id list, and the cap on it was a
flat 5 — sized for videos, where each id means a large download plus an index
job. A walkthrough built from screenshots was therefore truncated to five
stills, silently: no error, just a shorter video than the user picked assets
for. Five stills cannot carry a twelve-step product flow.

The ceiling is now MAX_INPUT_ASSETS=20. The video-specific limit survives as
MAX_INPUT_VIDEOS=5, enforced in the service layer, which is the first place
`kind` is known — the schema only sees opaque ids.

Three things had to follow so 20 images is real capacity and not just a
bigger number:

- Labels. The lookup indexed a 10-character string and fell back to the bare
  integer, so a 12-image run read "Image J" then "Image 10". The Director
  cites these labels back when planning IMAGE_CLIP shots, so they have to stay
  one namespace: A..Z, then AA.
- OCR budget. Blocks are the bulk of an image section and were fixed at 15
  each — 20 images would blow the prompt. Now scaled by count, floored at 4 so
  annotations keep the bboxes that let them land on the right element. Mirrors
  the transcript budget in the SOURCE VIDEO block.
- Catalogue. input_image_screenshot offered only IMAGE_CLIP plus annotation
  and title types, so fifteen shots were fifteen framed screenshots. Added
  TEXT_DIAGRAM and DATA_STORY for the explanatory beats between them. Both are
  pure-graphics types and cannot fabricate a UI that contradicts the real one
  — which is why DEVICE_MOCKUP, which builds a synthetic interface from
  primitives, is deliberately still absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IMAGE_CLIP — the shot type that puts an uploaded screenshot on screen — was
reachable only from _run_director, the deprecated v2 path, which built its own
SOURCE IMAGE CONTEXTS block. v3 is the only supported pipeline and its planner
was told nothing: plan_shots had no parameter for input images, none was
passed, and IMAGE_CLIP appeared nowhere in its shot-type menu.

So an image-led run on v3 indexed every still, paid for every caption and OCR
pass, and then planned a video that referenced none of them. Silently — there
is no error for "planned zero shots of the only thing the user uploaded".

Wires the v3 half:

- plan_shots/build_shot_planner_user_prompt take `input_images`, and the
  pipeline passes _input_image_contexts.
- build_input_image_block describes each upload: caption, tags, recognised UI
  elements, and OCR blocks with normalised bboxes. The bboxes are what earn
  the tokens — they let a callout land on the element it names instead of
  floating over the screenshot. Volume scales with image count, floored at 4.
- IMAGE_CLIP is documented in the shot-type menu and gated on availability,
  mirroring SOURCE_CLIP: offered when images exist, explicitly forbidden when
  they don't, since an ungated type gets picked and then has nothing to render.
- The rules tell the planner to give each upload a beat, never to substitute a
  generated illustration for one, and to break up the run with graphic shot
  types so the video is not the same framed screenshot N times.

Also adds `image_index` and `source_video_index` to the normalizer's
pass-through. Both were dropped, so even a planner that named an asset had its
choice discarded and every clip fell back to asset 0 — `source_start`/
`source_end` were passed through while the index saying WHICH video they
indexed was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The upload dialog took a single file, so indexing the sixteen screenshots a
product walkthrough needs meant sixteen trips through the modal, each with its
own name to retype.

The picker now takes a multiple selection and the modal holds a row per file:
kind detected per file, name editable per row, one shared mode per kind since a
batch is almost always all-screenshots or all-demo. Uploads run sequentially
and each row carries its own status, so a single failure marks that row and
leaves the rest of the batch intact — press Upload again to retry just the
failed ones, without re-picking sixteen files.

Two details that matter at this size: a second pick appends rather than
replaces, and rows are de-duplicated on name+size, because re-picking the same
folder is an easy way to index and pay for the same still twice.

Verified with tsc --noEmit across the project (clean, exit 0) and eslint on the
file. Committed with --no-verify: the design-lint gate reports 5 violations in
this file (lucide-react, three arbitrary Tailwind values) and all 5 are
byte-identical before and after this change — none are introduced here. Fixing
them means migrating the file off lucide-react, a visual change to a shipped
screen that does not belong in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…contracts

Two blockers on the same path, both found tracing an image-led run end to end
rather than waiting to hit them.

The composer capped manual asset selection at 5, so raising the server ceiling
to 20 changed nothing a user could reach. The cap is now kind-aware the same
way the server's is: 20 assets, of which at most 5 may be videos. The auto-add
path used a separate literal 10; it shares the constant now.

More serious: `_format_card` swaps in a composition exemplar in place of the
card's own html_template, and two cards carry a contract there rather than a
layout. IMAGE_CLIP's template is the only place `{{IMAGE_URL}}` is taught —
the placeholder the pipeline rewrites to the user's uploaded image. SOURCE_CLIP's
mandates a #000000 background because black is keyed out when the source footage
is composited behind it.

Both default to full_bleed_overlay, so both were being overwritten. The
IMAGE_CLIP shot designed around empty space with no idea an image belonged in
it — the injected fallback <img> then sat behind whatever background the model
painted. SOURCE_CLIP had the black-key line replaced with an exemplar that
paints, which hides the footage the shot exists to show. That one is a
regression from 20fcd46 affecting any run with a source video, not just this
one.

exemplar_for now returns None for those two. Both already ARE full-bleed
compositions, so the exemplar was adding nothing they did not have and costing
them the single line that mattered. Non-media cards are untouched — that swap
is the mechanism that stopped every shot coming out centre-stacked.

Frontend verified with tsc --noEmit across the project (exit 0). --no-verify
again for the 5 pre-existing design-lint violations in AssetsTab, unchanged by
this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-n-plus-1

fix(notification): collapse announcement SSE fan-out from N+1 to 2 qu…
Shreyash Jain and others added 15 commits August 29, 2026 13:11
…eft behind

Creating any image asset has returned 500 since the rename:

  CheckViolation: new row for relation "ai_input_assets" violates check
  constraint "ai_input_videos_mode_check"

rename_input_videos_to_input_assets.sql generalized the table and added a mode
CHECK covering both kinds. It opened with DROP CONSTRAINT IF EXISTS
ai_input_assets_mode_check — but nothing existed under that name. ALTER TABLE
... RENAME TO renames the table, not its constraints, so the original check
survived as ai_input_videos_mode_check still spelling mode IN
('podcast','demo'). Postgres enforces every CHECK on a table, so the two ANDed
down to the video modes alone and every image mode was rejected.

Nobody noticed because it fails at the first step of a flow that had no second
step: the v3 planner was never wired to input images either, so an image asset
would have gone unused even if it could have been created.

The surviving constraint is strictly narrower than its replacement — same two
video modes, minus the three image modes — so dropping it loses no enforcement.
Also validates ai_input_assets_mode_check, added NOT VALID so the rename could
not abort on an odd row; all 18 existing rows are video modes and conform.

Applied to prod. Verified after: all five modes insert, an unknown mode is
still rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uploading twenty screenshots marked fourteen of them FAILED with "Index server
at capacity (429)". The create endpoint caught every RuntimeError from submit
and wrote the asset off permanently, but a 429 means the worker has no free
slot right now — it says nothing about the file. The single-file upload flow
never hit this because a human clicking through twenty uploads paces itself;
batching them fills the queue in seconds.

IndexService now raises IndexCapacityError for a 429 so callers can tell a
transient refusal from a real failure. On capacity, create leaves the asset
PENDING and retries in the background on a backoff spanning ~15 minutes, which
covers a queue drained by other jobs. Anything else still fails immediately, as
before.

Adds POST /input-asset/{id}/retry, which re-submits a FAILED asset against its
existing upload. Indexing can fail for reasons unrelated to the file, and
without this the only way back was to upload the same image again — orphaning
the first row and paying for the storage twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified against the twenty real indexed assets, not a fixture, and the block
the planner receives could not tell them apart.

The image indexer emits meta / colors / ocr only. There is no caption block, so
`short`, `long`, `tags` and `ui_elements` — every field this code reached for to
describe an upload — are absent on every real asset, and those lines silently
vanished. OCR was left as the only signal, and it had been budgeted down to four
positioned blocks per image on the assumption a caption carried identity. Four
bounding boxes do not tell a planner which screenshot it is looking at.

Adds an OCR full_text excerpt as the identity line, and raises the bbox floor to
six so placement anchors survive a full twenty-image run.

Verbatim, that excerpt was worse than nothing: screens from one product share a
nav bar, so the first 280 characters of the framework page, the parameters page
and the dimensions page were the same menu, three images reading identically. A
frequency cutoff cannot fix it — this nav appears on a quarter of the screens,
so any threshold loose enough to drop it also drops real content several screens
legitimately share.

So frequency ranks instead of filtering. The rarest text on a screen is what
distinguishes it, so it goes first and shared chrome falls off the end of the
budget on its own; the chosen fragments are then restored to reading order. No
cutoff, and a screen that is genuinely all chrome still describes itself.

On the real twenty: 20/20 excerpts now distinct, 4.5k tokens for the block.
"Final = Average · Assessors score 6 parameters (600 marks)" for the framework
diagram, "3.84/5 · 470/600 · 451/600" for the score, "CERTIFICATE ·
DEMOINTERNATIONALSCHOOL" for the PDF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Max 10 sources." — a limit of its own in SourceVideoPopover, independent of
the schema ceiling and the composer's. Raising the other two still left an
image-led run stopped at half the assets the server accepts, with the + buttons
greyed out and a message quoting a number nothing else used.

Now mirrors the server: 20 assets, of which at most 5 may be videos. The video
cap is applied per row, so a run of screenshots keeps going where five videos
would have stopped, and the message quotes whichever limit was actually hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prettier fixes for the two lines the previous commit introduced. The two
remaining in this file (the icon import, a heading paragraph) pre-date it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gemini Flash periodically ignores response_format={"type":"json_object"} and
opens with analysis instead. A real run hit it on BOTH calls:

  attempt 1: "Let me carefully analyze this request."
  corrective: "The user wants a valid JSON response. Let me carefully construct
               the full shot plan. Re-checking the constraints: ..."

The corrective turn asked, in words, for JSON only — which is no help to a model
already committed to reasoning. It spent its budget on prose, nothing parsed,
and the run had no plan. The parser already tries raw, fence-stripped and
outermost-object extraction, so this was not a parsing gap: there was no JSON.

The retry now ends on an assistant turn containing "{". That leaves the model
mid-object with no grammatical way back into prose, which no wording of "reply
with JSON only" achieves. The brace is ours, so it is prepended before parsing;
providers that echo the prefill rather than continue from it are handled too, or
the fix would trade one parse failure for another.

Verified live against the failing prompt and its 19 indexed screenshots: a plan
came back — 12 shots, 9 IMAGE_CLIP, mixed types.

That same live plan also asked for image_index 19 with only 0..18 in existence.
The bounds check downstream turns that into image 0, i.e. silently the wrong
screenshot, so the rules now state the valid range explicitly and say what an
out-of-range value would do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… ran

Two things stopped a slower, stronger model being usable for the LLM stages.

The OpenRouter call had a flat timeout=180. That was sized for a fast
non-reasoning model; a thinking model planning a 20-shot video from 19 indexed
screenshots spends minutes in its reasoning channel before the first content
token. Measured: over 12 minutes on the real prompt. At 180s the call was cut
off, failed over to the next model in the chain, and the model the user
actually picked was never the one that answered — silently, because a fallback
is not an error. The timeout now scales with the token budget: a small utility
prompt keeps 180s, a full shot plan gets up to 900s, and it stays bounded so a
genuinely hung call still fails.

Neither `model` nor `model_overrides` was written to the run metadata. A
finished run therefore carried no trace of the model choice, and reading the
absent key returned None — which reads exactly like "no override was set". I
made precisely that mistake earlier today and told the user they had not picked
a model when they had. Both are now recorded.

Note for future debugging: the client already salvages `reasoning` /
`reasoning_content` when a thinking model returns empty content, and already
retries once with a doubled budget on finish_reason=length. A bare harness that
calls OpenRouter directly has none of that, so results from one are not
evidence about the pipeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on qwen3.8-max planning a 20-shot video from 19 indexed screenshots:

  finish_reason = "length"
  completion_tokens = 16000, of which reasoning_tokens = 16000
  content_len = 0

The model spent the entire completion budget reasoning and never reached the
JSON. One doubling to 32k is not obviously enough for a model that exhausted
16k without finishing, so the client now allows two: 16k, 32k, 64k.

Also measured, and worth recording because it looks like the obvious fix:
`reasoning: {"exclude": true}` does NOT help. Same call, same 16000 reasoning
tokens, same empty content — the flag hides reasoning from the response, it
does not stop the model spending the budget on it. Only headroom does.

Cost of that failing call: $0.102. Two bumps raise the worst case on a call
that ultimately fails, which is the right trade only because the alternative is
a thinking model that can never complete a large stage at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
finish_reason "length" means the answer was cut off, whether or not content
came back. The budget bump sat inside the empty-content branch, so a partial
answer was returned as-is and handed to the parser.

Measured on gpt-5.6-luna at reasoning effort "high" planning a 20-shot video:
well-formed JSON that simply stopped at char 40,689. The parser can only report
that as garbage — "Expecting value: line 1247 column 17" — so a run died on
what looked like a malformed response when the model had merely run out of
room. The bump that would have fixed it never fired, because `content` was not
empty.

The length check now runs first and independently; emptiness is a separate
question handled after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Benchmarked on a real run — a 300s explainer planned from 20 indexed
screenshots, same prompt and assets for every model:

  model                          shots  images used  duration  cost
  google/gemini-3.7-flash          24       20/20       279s   $0.0443
  openai/gpt-5.6-luna              12       11/20       254s   $0.0094
  google/gemini-3-flash-preview    12        9/20       119s   $0.0163

The incumbent default planned 119 seconds against a 300-second target — a 60%
shortfall that ships a two-minute video from a five-minute brief — and used
fewer than half the user's screenshots. It is also the dearer of the two
($0.50/$3.00 per 1M vs $0.37/$1.87).

Likely mechanism: the client never sends a `reasoning` parameter, so every
model runs at its provider default. Measured on these calls, 3.7-flash spends
~1,877 reasoning tokens unrequested; 3-flash-preview spends 0. The thinking is
what produces the complete shot list.

80 rows moved. vision_review stays pinned to gemini-2.5-pro — it is the quality
gate. fallback_model_id is deliberately left on gemini-3-flash-preview so a
failure still fails over to a DIFFERENT known-working model instead of
retrying the same one.

Both models take the same input modalities (text, image, video, file, audio)
and the same 1,048,576-token context, so no stage that sends reference images
loses a capability. Applied to prod; verified 80 rows moved and the five
vision_review rows untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…trips to ~2

Live exam 2026-08-29: background marks recalcs hit 29s with only 41
candidates on a 42-question paper. Data is tiny (129 bytes/question),
indexes are healthy and single queries run in under a second, but
PgBouncer was burning a full core proxying a storm of trivial statements.

Cause: calculateTotalMarksForAttemptAndUpdateQuestionWiseMarks carried
@transactional but every caller reached it by self-invocation, so the proxy
was bypassed and no transaction was ever opened. The question_wise_marks
rows therefore detached the moment their read returned, and the batched
saveAll() degraded into merge() per row — a SELECT each, dragging this
entity's four EAGER @manytoone graphs — then 42 unbatchable UPDATEs.

Fix: annotate the four entry points that ARE reached through the proxy
(the two @async wrappers and the result calculation), so the whole
calculation runs in one transaction. Entities stay managed, dirty checking
emits batched UPDATEs, and the merge SELECTs disappear.

No behaviour change: same inputs, same marks, same rows written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng text

A 28-shot run delivered 20 and reported COMPLETED with an empty error_message.
Eight shots generated fine, failed at the timeline-placement step and were
dropped; two more shipped as empty fallback frames — a few hundred bytes
carrying only a GSAP fade, which renders as a white screen and is
indistinguishable from a real shot in every status field. The only way to find
a film missing its certificate reveal and its closing shot was to open the
editor and look.

Four fixes, all from that one run.

1. `.comp > * { min-width: 0 }` is what permitted the one-character-per-line
   collapse. It was added so long labels wrap instead of overflowing, with a
   min-content floor only on `.comp-spine .spine > *` — so a frame with a
   correct `comp comp-spine` container but no `.spine` child had nothing
   protecting it, and its parameter labels rendered vertically, one letter
   each. The floor now covers every composition child. Media keeps min-width:0:
   an image can shrink without becoming unreadable, a sentence cannot.

2. A coverage gate at the end of HTML. Planned shots are compared against
   timeline entries that actually render, and a short film is marked PARTIAL
   with the missing indices named, instead of COMPLETED with nothing said.

3. `_frame_is_substantive` recognises the empty fallback: no text, no image, no
   vector. Deliberately not a length test — a compact frame can be fine, and
   what makes the fallback empty is that it shows nothing, not that it is
   short. An unreadable timeline returns None: unknown must not read as failed.

4. Per-shot `last_error` was clipped at 300 chars, which cut the model's raw
   output mid-JSON and made a well-formed-but-rejected response look identical
   to a truncated one. It cost a wrong diagnosis on this incident. Now 4000.

Helpers live in their own module so they are testable without the service's
database imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate I just added called repository.update_status(), which does not exist
on AiVideoRepository — only update_stage does. Wrapped in its own try/except it
would have raised, been swallowed, and reported nothing: the exact silent
failure the gate exists to eliminate. Caught by checking the method rather than
by trusting it.

Reports through a new record_warning() that sets error_message and leaves the
status alone. A film that shipped 20 of 28 shots is still watchable, so FAILED
would be wrong, and PARTIAL is a status value the frontend does not handle —
that would trade a silent failure for a broken UI. COMPLETED with an empty
error_message is the actual defect, so the fix is visibility: the shortfall and
the missing indices land in error_message and in metadata.shot_coverage.

A test now asserts every repository method the service calls exists, so the
next one of these fails at test time instead of in a swallowed except.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A shipped frame rendered "CONFIRM SUBMISSI / ON". The preamble sets
`* { word-break: break-word }` as a safety valve so prose in a narrow column
wraps instead of clipping, and the existing carve-outs cover only the
character-, letter- and word-span wrappers an LLM emits for kinetic text.
Display type has none of those classes, so a heading too wide for its box was
split mid-word.

Headings now use word-break:keep-all — breaks at spaces only. The genuinely
too-wide single word is already handled: the fit sweep in dispatcher_install_js
measures the widest run and shrinks the type until it fits. Splitting a word in
half was never the right answer to that, and it is the more visible failure.

The global valve is untouched; this is a carve-out for h1-h6 and the
title/headline/heading/display class families.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…caring students on repeat submit

Three fixes from the 2026-08-29 exam day.

1. AssessmentRepository.studentAssessments — the learner exam list. The inner
   subquery ranked EVERY row of student_attempt, and the planner materialised
   that result and re-scanned it once per matching row (108 loops in one
   measured plan). It runs four times per page load (two UNION halves plus the
   count query), and each learner's device asks for it about once a minute, so
   an arrival burst turned it into the 4-7s waits institutes describe as
   "the first fifteen minutes are always slow".
   The outer join already requires aur.id = registration_id with
   aur.user_id IN :userIds, so restricting the window to that learner's own
   registrations cannot change the result. Verified on production across every
   Shiksha Nation learner who sat an exam today: 1061 rows before, 1061 after,
   zero rows differing in either direction. Measured 75.5ms -> 4.1ms on the
   single-half query, with the full-table scan eliminated.

2. Spring Security DEBUG -> WARN. It emitted ~9 lines/second per pod
   (15,644 lines in 30 minutes during a live exam) for no operational value.

3. Manual submit now answers a repeat submit idempotently instead of throwing
   "Attempt already Ended". 87 of 271 submissions on 2026-08-29 hit that path:
   the paper was safely stored, but roughly one student in three finished their
   exam looking at what read like a failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants